ElixirとPhoenixでCRUDなWebアプリケーションを作る
Phoenixは生産性、信頼性、速度に強みを持つElixir製のWebアプリケーションフレームワークです。 前回は環境準備とPhoenixアプリケーションを作成するところまで書きました。
高生産性、高信頼性、高速のElixir製Webアプリケーションフレームワーク、Phoenixを始める
その続きで、今回はCRUDなWebアプリケーションを作成したいと思います。
プロジェクト作成
以下のコマンドでプロジェクトを作成します。
$ mix phoenix.new hello
プロジェクトができたら、前回の記事を元にプロジェクトの初期化を行ってください。
Modelの定義
PhoenixではDatabase操作にEcto
というモジュールを使います(RailsのActiveRecordに相当するものですが、Elixirは関数型言語のためORマッパーではありません)。
Ectoについては今回深く説明しませんが、特徴はデータベース操作やクエリの組み立てを関数合成のように出来るところや、Changesets
という機能でバリデーションやパラメータのキャストを行うところです。
それではユーザーのmodelクラスを定義しましょう
hello/web/models/user.ex
defmodule Hello.User do use Hello.Web, :model schema "users" do field :name, :string field :email, :string field :password, :string, virtual: true field :password_hash, :string timestamps end @required_fields ~w(name email) @optional_fields ~w() end
DSLでスキーマを定義しています。このschema
やfield
はmacroで定義されていて、それぞれのフィールド名はテーブルの各カラム名と一致します。
コードでは書かれていないですが、テーブルの主キーであるid
フィールドは自動で定義されます。
また、passwordフィールドにはvirtual
の指定がありますが、このフィールドはDBには存在しません。
DBのpassword_hashフィールドにハッシュ化したパスワードを登録するので、そのためのパスワードを保持するフィールドです。
続いて、いま定義したUserクラスの情報を保存するUserテーブルのmigrationファイルを作成しましょう。
以下のコマンドで作ります。
$ mix ecto.gen.migration create_user
生成されたファイルにindexを追加します(create uniqu_index..
のところです)。
hello/priv/repo/migrations/yyyyMMddHHmmss_create_user.exs
defmodule Hello.Repo.Migrations.CreateUser do use Ecto.Migration def change do create table(:users) do add :name, :string add :email, :string, null: false add :password_hash, :string timestamps end create unique_index(:users, [:email]) end end
ファイルを作成したら、以下のコマンドでmigrationを実行しUserテーブルを作ります。
mix ecto.migrate
Controllerの定義
ルートの定義
まず、router.ex
にユーザー情報のルートを書きます。
hello/web/router.ex
scope "/", Hello do pipe_through :browser resources "/users", UserController end
reourcesの定義をすると、index, show, edit, update, deleteのルートが定義されます。
試しにコマンドでルートの確認をしてみましょう。
$ mix phoenix.routes user_path GET /users Hello.UserController :index user_path GET /users/:id/edit Hello.UserController :edit user_path GET /users/new Hello.UserController :new user_path GET /users/:id Hello.UserController :show user_path POST /users Hello.UserController :create user_path PATCH /users/:id Hello.UserController :update PUT /users/:id Hello.UserController :update user_path DELETE /users/:id Hello.UserController :delete
もちろん、resources "/users"
ではなく、以下のように定義しても同じようにルート定義できます。
hello/web/router.ex
scope "/", Hello do pipe_through :browser get "/users", UserController, :index get "/users/:id", Usercontroller, :show # その他のメソッドについても同様に定義する end
indexメソッド
それではcontrollerクラスを作成します。
まず、indexメソッドです。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do use Hello.Web, :controller alias Hello.User def index(conn, _params) do users = Repo.all(User) render conn, "index.html", users: users end end
Repo.all(User)
でDBにあるユーザー一覧を取得してビューをレンダリングしています。
alias Hello.User
はHello.User
モジュールをUser
だけで呼び出せるようにするために記述しています。
次にビューファイルを作成しましょう。
Phoenixでは一つの画面でview
とtemplate
の二つのファイルを作ります。
それぞれ以下の役割を持っています。
- viewはデータを表示用に加工するための関数を定義するモジュール
- templateはHTMLもしくはJSONに変換されるファイル
templateファイルが従来のWeb Application Frameworkのviewファイルに近いですね。
viewファイルはとりあえず以下のように空のまま定義します。
hello/web/view/user_view.ex
defmodule Hello.UserView do use Hello.Web, :view end
templateファイルも作成しましょう。
hello/web/templates/user/index.html.eex
<div class="row"> <aside class="col-md-4"> <%= for user <- @users do %> <section> <%= render "user.html", user: user, conn: @conn %> <%= link "Show", to: user_path(@conn, :show, user), class: "btn btn-default btn-xs" %> <%= link "Edit", to: user_path(@conn, :edit, user), class: "btn btn-default btn-xs" %> </section> <% end %> </aside> </div>
hello/web/templates/user/user.html.eex
<a href="<%= user_path(@conn, :show, @user) %>"> <img src="<%= get_gravatar_url(@user) %>" class="gravatar"> </a> <h1><%= @user.name %></h1>
(get_gravatar_urlはユーザーのavator画像を表示するための関数です、後ほど説明します)
newメソッド
新規ユーザーを作成するnewメソッドを追加します。
hello/web/controller/user_controller.ex
defmodule Hello.UserController do # 省略 # ここを追加 def new(conn, _params) do changeset = User.changeset(%User{}) render conn, "new.html", changeset: changeset end end
User.changeset(%{User{})
はこれからUserクラスに作る関数です。
パラメータを受け取って、パラメータのキャスト、バリデーション、レコードの変更を行い、処理結果を保持しているEcto.Changeset
を返却するようにします。ここではユーザーの作成に必要なフォーム情報を作成し返却しています。
それではUserモデルにchangeset関数を追加します
hello/web/models/user.ex
defmodule Hello.User do # 省略 # ここを追加 def changeset(model, params \\ :empty) do model |> cast(params, @required_fields, @optional_fields) |> validate_length(:name, min: 1, max: 20) |> validate_format(:email, ~r/@/) |> unique_constraint(:email) end end
ここではパラメータを必須フィールド、任意フィールドに割り当て、それぞれのフィールドのバリデーションを実行しています。
Railsの場合、各フィールドのバリデーションをフィールドの定義と同時に行っているのでここはPhoenixとRailsの違いが明確に出ています。
templateファイルも作成します
hello/web/templates/user/new.html.eex
<h1>New User</h1> <%= form_for @changeset, user_path(@conn, :create), fn f -> %> <%= if @changeset.action do %> <div class="alert alert-danger"> <p>エラーです、以下のメッセージを確認してください</p> </div> <% end %> <div class="form-group"> <%= text_input f, :name, placeholder: "Name", class: "form-control" %> <%= error_tag f, :name %> </div> <div class="form-group"> <%= text_input f, :email, placeholder: "Email", class: "form-control" %> <%= error_tag f, :email %> </div> <div class="form-group"> <%= password_input f, :password, placeholder: "Password", class: "form-control" %> <%= error_tag f, :password %> </div> <%= submit "Create User", class: "btn btn-primary" %> <% end %>
createメソッド
ユーザー作成画面から呼ばれるcreateメソッドを作成します。
ユーザーのレコードをinsertし、成功したらユーザー一覧画面に遷移させます。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do # 省略 # ここから追加 def create(conn, %{"user" => user_params}) do changeset = User.changeset(%User{}, user_params) case Repo.insert(changeset) do {:ok, user} -> conn |> put_flash(:info, "#{user.name}を作成しました") |> redirect(to: user_path(conn, :index)) {:error, changeset} -> render(conn, "new.html", changeset: changeset) end end end
show
ユーザー詳細画面と画面を呼び出すshowメソッドを追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do # 省略 # ここから追加 def show(conn, %{"id" => id}) do user = Repo.get(User, id) render conn, "show.html", user: user end end
hello/web/templates/user/show.html.eex
<div class="row"> <aside class="col-md-4"> <section> <%= render "user.html", user: @user, conn: @conn %> <%= link "Edit", to: user_path(@conn, :edit, @user), class: "btn btn-default btn-xs" %> <%= button "Delete", to: user_path(@conn, :delete, @user), method: :delete, onclick: "return confirm(\"本当に削除しますか?\");", class: "btn btn-danger btn-xs" %> <%= link "Back", to: user_path(@conn, :index), class: "btn btn-default btn-xs"%> </section> </aside> </div>
edit, update
ユーザー更新のメソッドと更新画面を作成します
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do # 省略 # ここから追加 def edit(conn, %{"id" => id}) do user = Repo.get(User, id) changeset = User.changeset(user) render(conn, "edit.html", user: user, changeset: changeset) end def update(conn, %{"id" => id, "user" => user_params}) do user = Repo.get(User, id) changeset = User.changeset(user, user_params) case Repo.update(changeset) do {:ok, user} -> conn |> put_flash(:info, "更新しました") |> redirect(to: user_path(conn, :show, user.id)) {:error, changeset} -> render(conn, "edit.html", user: user, changeset: changeset) end end end
templateファイルも用意します。フォームを別ファイルに切り出して共通化します。
hello/web/templates/user/edit.html.eex
<%= render "form.html", changeset: @changeset, action: user_path(@conn, :update, @user) %> <%= link "Back", to: user_path(@conn, :index) , class: "btn btn-default btn-xs" %>
hello/web/templates/user/form.html.eex
<%= form_for @changeset, @action, fn f -> %> <%= if @changeset.action do %> <div class="alert alert-danger"> <p>error! Please check the errors below.</p> </div> <% end %> <div class="form-group"> <%= label f, :name, class: "control-label" %> <%= text_input f, :name, class: "form-control" %> <%= error_tag f, :name %> </div> <div class="form-group"> <%= label f, :email, class: "control-label" %> <%= text_input f, :email, class: "form-control" %> <%= error_tag f, :email %> </div> <div class="form-group"> <%= submit "Submit", class: "btn btn-primary" %> </div> <% end %>
delete
最後にユーザー削除のメソッドを追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do # 省略 # ここから追加 def delete(conn, %{"id" => id}) do user = Repo.get(User, id) Repo.delete(user) conn |> put_flash(:info, "削除しました") |> redirect(to: user_path(conn, :index)) end end
認証、認可
このままですと、認証していない状態でも各操作ができてしますので認証の仕組みを作ります。
comeonin
というライブラリーを利用して簡単なパスワード認証機能を実装します。
comeonin
パスワード認証
mix.exsファイルにcomeonin
を追加します。
hello/mix.exs
defmodule Hello.Mixfile do use Mix.Project # 省略 # applications:に:commeoninを追加する def application do [mod: {Hello, []}, applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext, :phoenix_ecto, :postgrex, :comeonin]] end # 省略 # 依存ライブラリにcomeoninとそのバージョンを指定します defp deps do [{:phoenix, "~> 1.1.4"}, {:postgrex, ">= 0.0.0"}, {:phoenix_ecto, "~> 2.0"}, {:phoenix_html, "~> 2.4"}, {:phoenix_live_reload, "~> 1.0", only: :dev}, {:gettext, "~> 0.9"}, {:cowboy, "~> 1.0"}, {:comeonin, "~> 2.0"}] end # 省略 end
以下のコマンドでライブラリをダウンロードします
mix deps.get
続いてUserクラスにユーザー登録時のパスワードチェック、ハッシュ化処理を追加します
hello/web/models/user.ex
defmodule Hello.User do # 省略 # ここから追加 def registration_changeset(model, params) do model |> changeset(params) |> cast(params, ~w(password), []) |> validate_length(:password, min: 6, max: 100) |> put_pass_hash() end defp put_pass_hash(changeset) do case changeset do %Ecto.Changeset{valid?: true, changes: %{password: pass}} -> put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass)) _ -> changeset end end end
定義済みのchangeset(params)
でパスワード以外のパラメータのバリデーションを行います。
そのあとにパスワードのバリデーション(文字列長チェック)を行い最後にput_pass_hash()
でパスワードをハッシュ化してDBに保存しています。
controllerのcreateメソッドを、registration_changeset
メソッドを使用するように修正します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do # 省略 def create(conn, %{"user" => user_params}) do # ここをregistration_changesetに変更 changeset = User.registration_changeset(%User{}, user_params) case Repo.insert(changeset) do {:ok, user} -> conn |> put_flash(:info, "#{user.name}を作成しました") |> redirect(to: user_path(conn, :index)) {:error, changeset} -> render(conn, "new.html", changeset: changeset) end end end
認証、認可モジュール
認証、認可用のモジュールを作り複数のコントローラから使用できるようにします。
また、ログイン、ログアウトのメソッドもこのモジュールに記述しましょう。
init
とcall
メソッドはPlugとして必ず必要なメソッドです。詳細は今回説明しませんが、initで取得したconn(Plug.Conn構造体)をcallで受け取りDB接続処理を行っています。
hello/web/controllers/auth.ex
defmodule Hello.Auth do import Plug.Conn import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0] import Phoenix.Controller alias Hello.Router.Helpers alias Hello.User def init(opts) do Keyword.fetch!(opts, :repo) end def call(conn, repo) do user_id = get_session(conn, :user_id) user = user_id && repo.get(User, user_id) assign(conn, :current_user, user) end def login_by_name_and_pass(conn, name, given_pass, opts) do repo = Keyword.fetch!(opts, :repo) user = repo.get_by(User, name: name) cond do user && checkpw(given_pass, user.password_hash) -> # ここでpasswordのチェックが成功した場合にloginメソッドを呼び出してセッションにユーザーのidを保存しています(処理はloginメソッドに委譲しています) {:ok, login(conn, user)} user -> {:error, :unauthorized, conn} true -> dummy_checkpw() {:error, :not_found, conn} end end def login(conn, user) do conn |> assign(:current_user, user) |> put_session(:user_id, user.id) |> configure_session(renew: true) end def logout(conn) do configure_session(conn, drop: true) end # 認可用メソッド def authenticate_user(conn, _opts) do if conn.assigns.current_user do conn else conn |> put_flash(:error, "ログインしてください") |> redirect(to: Helpers.session_path(conn, :new)) |> halt() end end end
コントローラ、ルーターで共通してauthenticate_user
メソッドを使用できるようにweb.ex
にも追加します
hello/web/web.ex
defmodule Hello.Web do # 省略 def controller do quote do use Phoenix.Controller alias Hello.Repo import Ecto import Ecto.Query, only: [from: 1, from: 2] import Hello.Router.Helpers import Hello.Gettext import Hello.Auth, only: [authenticate_user: 2] # ここを追加 end end # 省略 def router do quote do use Phoenix.Router import Hello.Auth, only: [authenticate_user: 2] end end end
認可処理がリクエスト時に呼ばれるようにrouterを変更します。
pipeline :browser
とresouces "/users"
の中に今回作ったモジュールと、認可用メソッドのauthenticae_user
を追加します。
hello/web/router.ex
defmodule Hello.Router do use Hello.Web, :router pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers plug Hello.Auth, repo: Hello.Repo # ここを追加 end scope "/", Hello do pipe_through :browser get "/", PageController, :index resources "/users", UserController do pipe_through [:authenticate_user] # ここを追加 end end end
続いて、コントローラの各メソッドで認可処理が走るように記述を追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do use Hello.Web, :controller alias Hello.User plug :authenticate_user when action in [:index, :show, :edit, :update, :delete] # ここを追加 # 省略 end
ログイン、ログアウト
SessionController
という名称でlogin, logout処理を実装したコントローラを作成します。
まずはrouterに追加します。
hello/web/router.ex
defmodule Hello.Router do use Hello.Web, :router # 省略 scope "/", Hello do pipe_through :browser get "/", PageController, :index resources "/users", UserController do pipe_through [:authenticate_user] end resources "/sessions", SessionController, only: [:new, :create, :delete] end end
次にコントローラを作成します
hello/web/controllers/session_controller.ex
defmodule Hello.SessionController do use Hello.Web, :controller def new(conn, _) do render conn, "new.html" end def create(conn, %{"session" => %{"name" => user, "password" => pass}}) do case Hello.Auth.login_by_name_and_pass(conn, user, pass, repo: Repo) do {:ok, conn} -> conn |> put_flash(:info, "Welcome") |> redirect(to: page_path(conn, :index)) {:error, _reason, conn} -> conn |> put_flash(:error, "ユーザー名/パスワードが不正です") |> render("new.html") end end def delete(conn, _) do conn |> Hello.Auth.logout() |> redirect(to: page_path(conn, :index)) end end
ログイン画面用のviewとtemplateファイルを作成します。
hello/web/views/session_view.ex
defmodule User.SessionView do use User.Web, :view end
hello/web/templates/session/new.html.eex
<h1>Login</h1> <%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %> <div class="form-group"> <%= text_input f, :name, placeholder: "Name", class: "form-control" %> </div> <div class="form-group"> <%= password_input f, :password, placeholder: "Password", class: "form-control" %> </div> <%= submit "Log in", class: "btn btn-primary" %> <% end %>
アプリケーショントップ画面にユーザー登録画面とログイン画面へのリンクを追加しましょう。
ユーザーがセッションに存在する場合はログアウト、存在しない場合はユーザー登録、もしくはログイン画面へのリンクを表示しています。
hello/web/templates/layout/app.html.eex
<!-- 省略 --> <body> <div class="container"> <div class="header"> <ol class="breadcrumb text-right"> <%= if @current_user do %> <li><%= @current_user.name %></li> <li> <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete" %> </li> <% else %> <li><%= link "Register", to: user_path(@conn, :new) %></li> <li><%= link "Log in", to: session_path(@conn, :new) %></li> <% end %> </ol> <span class="logo"></span> </div> <!-- 省略 -->
その他微調整
avator画像
ユーザー画面が文字列だけだと寂しいのでGravatorに登録してあるイメージファイルを表示するようにします。
まずGravatorのAPIを叩くモジュールを作成します。
APIの詳細については説明を省きますが、emailのMD5値をパラメータとして渡すのでMD5暗号化する関数を作成しています。
hello/lib/Hello/gravator.ex
defmodule Hello.Gravatar do def get_gravatar_url(email) do gravatar_id = email_to_gravator_id(email) "https://secure.gravatar.com/avatar/#{gravatar_id}?s=50" end defp email_to_gravator_id(email) do email |> email_downcase |> email_crypt_md5 end defp email_crypt_md5(email) do :erlang.md5(email) |> :erlang.bitstring_to_list |> Enum.map(&(:io_lib.format("~2.16.0b", [&1]))) |> List.flatten |> :erlang.list_to_bitstring end defp email_downcase(email) do String.downcase(email) end end
作成した関数を呼び出すようにUserのviewファイルを変更します
hello/web/views/user_view.ex
defmodule Hello.UserView do use Hello.Web, :view alias Hello.{User, Gravatar} def get_gravatar_url(%User{email: email}) do Gravatar.get_gravatar_url(email) end end
CSS
cssで画面レイアウトの微調整をします。 以下のcssファイル作成します。
hello/priv/static/css/custom.css
html { overflow-y: scroll; } body { padding-top: 60px; } section { overflow: auto; } textarea { resize: vertical; } .center { text-align: center; } .center h1 { margin-bottom: 10px; } h1, h2, h3, h4, h5, h6 { line-height: 1; } h1 { font-size: 3em; letter-spacing: -2px; margin-bottom: 30ps; text-align: left; } h2 { font-size: 1.2em; letter-spacing: -1px; margin-bottom: 30px; text-align: center; font-weight: normal; color: #777777; } p { font-size: 1.1em; line-height: 1.7em; } .gravatar { float: left; margin-right: 10px; } aside section { padding: 10px 0; border-top: 1px solid #eeeeee; } aside section:first-child { border: 0; padding-top: 0; } aside section span { display: block; margin-bottom: 3px; line-height: 1; } aside section h1 { font-size: 1.4em; text-align: left; letter-spacing: -1px; margin-bottom: 3px; margin-top: 0px; } .content { display: block; } .timestamp { color: #777777; } aside textarea { height: 100px; margin-bottom: 5px; }
templateファイルでこのファイルを読み込むように変更します。
hello/web/templates/layout/app.html.eex
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="description" content=""> <meta name="author" content=""> <title>Hello Phoenix!</title> <link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>"> <!-- 以下の行を追加してください --> <link rel="stylesheet" href="<%= static_path(@conn, "/css/custom.css") %>"> </head> <body> <div class="container"> <div class="header"> <ol class="breadcrumb text-right"> <%= if @current_user do %> <li><%= @current_user.name %></li> <li><%= link "Register", to: user_path(@conn, :new) %></li> <li> <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete" %> </li> <% else %> <li><%= link "Register", to: user_path(@conn, :new) %></li> <li><%= link "Log in", to: session_path(@conn, :new) %></li> <% end %> </ol> <span class="logo"></span> </div> <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <main role="main"> <%= render @view_module, @view_template, assigns %> </main> </div> <!-- /container --> <script src="<%= static_path(@conn, "/js/app.js") %>"></script> <script src="http://code.jquery.com/jquery-2.1.4.min.js"></script> </body> </html>
画面の確認
それではここまでに作成した画面を表示してみましょう!
トップ画面
ログイン画面
ユーザー一覧画面
ユーザー詳細画面
ユーザー更新画面
ユーザー新規作成画面
ログインしないでアクセスした場合
まとめ
ユーザー情報のCRUD操作を実装してきました。Ruby on RailsのようなMVC Frameworkをオブジェクト指向言語ではなく関数型言語でも実現しているところがPhoenixの面白いところですね。
また、ここまで触ってみた感じではかなりRuby on Railsの開発スタイルに近いと感じました。Railsのような開発生産性とElixirによる並列処理性能の両方を実現したFrameworkというのは、他の言語でもなかなかなく非常に可能性を感じます。
まだ、関連を持ったリソースの複雑な操作やPhoenixプロジェクトのディレクトリ構成については説明していないので、次回以降書いていきたいと思います。